Skip to main content

05 - 文件系统式记忆

前置02 篇的抽取式写入、04 篇的检索管线。本篇是这两篇的替代路线。

本篇回答:不建向量库、不建图库,让模型自己读写一个目录,能走多远。

本篇会用到的词

意思
客户端执行工具模型只发出操作请求,实际执行在你的应用里。文件读写、路径校验都是你的代码在做
即时检索(just-in-time)不预先把所有相关信息塞进上下文,而是让模型在需要时自己去读。文件式记忆的核心主张
memFSLetta 对"记忆文件系统"的叫法,其记忆是一个 git 仓库而不是数据库表
记忆块(memory block)Letta v1 的记忆单元:一段带标签的文本,常驻在系统提示词里
路径穿越../ 之类的相对路径跳出限定目录,读到不该读的文件

一、这条路线的主张

前四篇里的记忆层有一个共同结构:系统替模型决定该记什么、该取回什么。抽取器挑事实、检索器挑条目,模型只是被动接收结果。

文件式记忆把这个决策权交回给模型:给它一个目录和六个文件操作,让它自己决定写什么文件、什么时候去读。

分野不在存储介质,在"谁决定记什么和取什么"检索式 —— 01 到 04 篇系统:抽取+ 检索模型:被动接收结果向量库 / 图库系统读写+ 行为可控,能做配额、审计、强制注入+ 检索延迟稳定,不占模型轮次− 抽取和检索都可能挑错,模型无从纠正文件式 �—— 本篇系统:只提供六个文件命令模型:自己决定写什么、读什么一个目录模型读写+ 没有抽取损失,模型按自己需要组织+ 记忆人可读可手改,排查成本极低− 每次读写都占一个模型轮次,慢且贵
"每次读写占一个模型轮次"是文件式最硬的约束:检索式一次并行检索几十毫秒拿回十条,文件式要 view 目录、view 文件、可能再 view 另一个文件,每一步都是一次完整的模型往返。

二、Anthropic memory 工具

工具声明只有两个字段,没有 input_schema —— 输入格式内置在模型里:

{"type": "memory_20250818", "name": "memory"}

2.1 六个命令

命令参数行为需要注意的错误语义
viewpath,可选 view_range目录返回两层深的列表(大小 + 路径,tab 分隔);文件返回带 6 位右对齐行号的内容空目录的第一次 view 不是错误。超过 16,000 字符的文件会被截断,模型会跟一次带 view_range 的读
createpath, file_text建文件,已存在则覆盖覆盖前应自己留备份 —— 协议不负责
str_replacepath, old_str,可选 new_str替换唯一一处;省略 new_str 即删除该段old_str 出现 0 次或多次都必须报错并说明,不能自作主张替换第一处
insertpath, insert_line, insert_text插到第 insert_line 行之后,0 表示插到开头行号越界要返回带合法区间的错误信息
deletepath递归删除文件或目录必须拒绝删除 /memories 根目录本身
renameold_path, new_path重命名或移动目标已存在时报错,不要覆盖

str_replace 那条"多处匹配必须报错"看着琐碎,实际是这套协议里最重要的一条安全设计:模型对文件内容的记忆是不精确的,它以为唯一的字符串常常出现多次。静默替换第一处会造成难以察觉的记忆损坏。

2.2 一次典型交互

五个方框=五次模型往返。检索式记忆完成同样的事只需要一次① view /memories看目录里有什么API 自动提示它这么做② view 某个文件读回进度或用户偏好长文件要分段读③ 干正事这一步才是用户要的可能自己还要调工具④ str_replace更新进度文件多处匹配会被打回⑤ 回复用户中断也没关系进度已经落盘了①不需要你在提示词里写 —— 只要 tools 里带了 memory 工具,API 会自动往系统提示词里加一段记忆协议,大意是「动手之前先 view 你的记忆目录」「随时可能被打断,没写进记忆的进度都会丢」。这也解释了为什么这条路线特别适合长任务:④是显式的、可中断的存档点,而不是隐式的会话状态。
官方给的多会话开发范式正是围绕④设计的:第一个会话先建好进度日志和功能清单,之后每个会话开头读、结尾写。判断一个功能"完成"的标准是端到端验证通过,不是代码写完 —— 否则进度日志会越来越不可信。

2.3 路径穿越是你的责任

/memories 只是一个前缀,你的处理器把它映射到真实存储。模型给出的 path不可信输入

from pathlib import Path

MEMORY_ROOT = Path("/srv/agent-memory/u_1024").resolve()

def resolve_memory_path(model_supplied: str) -> Path:
# 1) 前缀必须是 /memories —— 先挡掉一眼就不合法的
if not model_supplied.startswith("/memories"):
raise ValueError("path must start with /memories")

# 2) 映射到真实路径后 **必须 resolve()**:
# resolve() 会把 ../ 和符号链接都展开成规范形式。
# 只做字符串检查挡不住 /memories/a/../../../etc/passwd,
# 也挡不住 /memories/link -> /etc 这种符号链接。
candidate = (MEMORY_ROOT / model_supplied.removeprefix("/memories").lstrip("/")).resolve()

# 3) 展开之后再判断是否仍在根目录内。顺序不能反 —— 先判断再展开等于没判断。
if not candidate.is_relative_to(MEMORY_ROOT):
raise ValueError(f"path escapes memory root: {model_supplied}")

return candidate

除了 ../,还要挡 URL 编码形式(%2e%2e%2f)和反斜杠形式(..\\)。官方安全清单里另外三条:

  • 别存敏感信息。模型通常会拒绝把密钥写进记忆,但这是倾向不是保证;要强保证就在写入前做正则剥离
  • 限制文件大小与 view 返回长度,让模型用 view_range 分页读,否则一个膨胀的记忆文件会一次性吃掉上下文
  • 定期清理长期没被访问的文件 —— 也就是 03 篇第五节的遗忘,文件式一样躲不掉

三、Letta 的转向:从数据库记忆块到 git 目录

Letta(原 MemGPT)是"记忆块"这个概念的来源:一段带标签的文本常驻在系统提示词里,模型用专门的工具改写它。这套设计已经换代了。

letta-ai/letta 仓库现在只剩一个落地页,README 明写 V1 服务端"退役"、源码保留在 archive 分支且不再修复。当前实现在 letta-ai/letta-code(TypeScript,2025-10-25 建仓),记忆的形态变成了一个 git 仓库

// src/agent/memory-git.ts 的文件头注释
/**
* Git operations for git-backed agent memory.
*
* When memFS is enabled, the agent's memory is stored in a git repo
* on the server at $LETTA_MEMFS_BASE_URL/v1/git/$AGENT_ID/state.git
* ...
* This module provides the CLI harness helpers: clone on first run,
* pull on startup, commit memory writes, post-turn push for clean pending
* commits, and status checks for system reminders.
*/
同一个诉求「记忆要能被审查和回滚」,两代给出的答案��完全不同V1(已退役)· 数据库记忆块blocks 表label + value + 只读标记常驻系统提示词专用工具改写+ 记忆永远在上下文里,不需要检索− 容量硬受限于上下文,装不下就得摘要− 历史版本要自己建表存,没有天然的回滚− 每改一次记忆,前缀缓存全部失效V2(letta-code)· 服务端 git 仓库state.git每个 agent 一个仓库启动 clone / pull写入 commit,回合末 push+ 版本、diff、回滚、责任人全部现成+ 容量不受上下文限制,按需读文件− 引入了并发合并问题:两端同时改要处理− 网络故障、非快进推送都要重试与修复
V2 里仍保留了记忆块的概念(MEMORY_BLOCK_LABELS = ["persona", "human"]),但它退化成两个默认块;真正承载长期记忆的是 memFS 那个 git 目录,且 memory_filesystem 这个块被标成只读,模型不能直接改写它。

memory-git.ts 里那几条正则很说明问题 —— 它们枚举了这套方案在生产上会遇到的全部麻烦:

// 非快进推送:两个客户端同时改了同一个 agent 的记忆
const NON_FAST_FORWARD_PUSH_ERROR_RE =
/(non-fast-forward|fetch first|failed to push some refs|updates were rejected|...)/i;
// 历史不相关:本地仓库和服务端仓库不是同一条历史,通常是重装或手工操作留下的
const UNRELATED_HISTORY_PULL_ERROR_RE = /(no common commits|refusing to merge unrelated histories)/i;
// 网络瞬时故障:520-524 是 CDN 层错误,要重试而不是报错给用户
const RETRYABLE_GIT_HTTP_ERROR_RE = /(?:\bHTTP\s+(?:520|521|522|523|524)\b|...)/i;

这是选型时要正视的代价:把记忆做成 git 仓库,等于把分布式版本控制的全部失败模式引进了记忆层。换来的是免费的版本历史、diff 和回滚 —— 对"记忆被写脏了怎么办"这个问题,这是目前最干净的答案。

四、还有一类:人写的记忆

CLAUDE.mdAGENTS.md.cursor/rules 这类文件也是文件式记忆,只是写入者是人而不是模型。它们的特点:

维度模型写的记忆人写的记忆
内容从交互中学到的事实显式的约定与规范
出错方式抽错、过期、被投毒过时(代码改了文档没改)
审查需要专门建机制走代码评审,天然有
对应记忆分型语义 + 情景程序记忆(01 篇3.2)

两者可以共存于同一个目录,但不要让模型改写人写的那部分。Letta 用 READ_ONLY_BLOCK_LABELS 做这个隔离,memory 工具这边则要在处理器里按路径前缀拒绝写入。理由回到 01 篇 3.2:程序记忆改的是行为,且没有任何一轮对话会去纠正它。

五、文件式和检索式怎么选

维度文件式检索式
记忆规模几十个文件以内。目录列表本身要进上下文十万条以上没问题
读取延迟每次读一个文件=一次模型往返,数百毫秒到秒级一次并行检索,几十毫秒
检索精度靠模型看文件名猜,文件一多就开始猜错有召回率指标可以优化
排查成本极低。cat 一下就知道它记了什么高。要把检索结果打进 trace 才看得见
多用户隔离靠目录隔离,简单可靠靠 filter 字段,漏传就越界(02 篇第七节)
强制注入做不到。模型不去 view 你就没办法能。安全类记忆可以绕过排序直接注入
版本与回滚git 方案天然具备要自己建历史表

"强制注入做不到"是文件式最实质的短板。过敏源、禁忌药物这类记忆,检索式可以按 user_id 精确查询后无条件注入;文件式只能寄希望于模型每次都记得去 view。API 自动加的那段记忆协议("动手之前先看记忆目录")就是在补这个洞,但它是提示词层面的约束,不是机制层面的保证。

结论:单用户、长任务、记忆条目在几十条量级 —— 选文件式,它的排查成本优势非常实在。多用户、需要强制注入某些条目、记忆规模上万 —— 选检索式。两者混用是可行的:用文件式管"当前任务的进度与约定",用检索式管"这个用户的长期事实"。

六、小结

  • 文件式把"记什么、取什么"的决策权交给模型,代价是每次读写占一个模型轮次
  • memory 工具的六个命令里,str_replace 的"多处匹配必须报错"是最重要的安全设计 —— 模型对文件内容的记忆不精确
  • 路径校验必须是"先映射、再 resolve()、最后判断是否在根目录内",顺序反了等于没判断
  • Letta 用 git 仓库承载记忆,换来免费的版本与回滚,代价是引入了分布式版本控制的全部失败模式
  • 人写的记忆(CLAUDE.md 一类)属于程序记忆,不要让模型改写它
  • 文件式的硬伤是无法强制注入;安全类记忆不适合只用文件式

下一篇:06 - 开源实现横评,把两条路线上的八个项目放在同一张表里,含两个会让人白花一周的选型陷阱。

← 回到 专题索引